(C) 에러 처리·API 설계 실습 — strtol my_atoi·snprintf_m·sum_file·conformant 배열
Properties1
tags
—
30 min read
에러 처리·API 설계 실습 — strtol my_atoi·snprintf_m·sum_file·conformant 배열
NOTE
C 기초 강의의 에러 처리(13-01 ~ 13-06)와 API 설계(14-01 ~ 14-04) 실습에서 만난 오류 패턴을 정리하고, my_atoi, snprintf_m, sum_file, **conformant 배열 파라미터를 쓰는 improved_strncpy/strncat**을 직접 구현하며 겪은 버그를 기록한다.
환경은 Docker Desktop의 Linux 컨테이너(c_app_part1), gcc -Wall -Wextra.
IMPORTANT
코드는 컨테이너에서 컴파일·실행해 확인했고, 설명은 AI(Claude)와 함께 코드 리뷰하며 정리했다.
in-band 에러 지시자를 피한다 → 에러는 반환값, 값은 인자로. snprintf 사용
13-03
abort()로 종료 → atexit·정리 미실행, 파일 버퍼가 써질지는 구현에 따라 다름
종료는 return/exit, fclose는 FILE *에
13-05
atoi는 에러를 알려주지 않음
strtol + endptr + errno
13-06
strtoul 전에 errno를 0으로 안 만듦
호출 직전 errno = 0, 호출 직후 바로 검사
14-01
인자(FILE *) 검사 없음, 읽기 루프가 끝난 이유를 구분 안 함
진입부에서 NULL 검사, 끝난 뒤 ferror / feof 구분
14-02
복사 함수에 대상 크기가 없고 반환값도 없음
dst_size 인자 + off-by-one 주의
14-03
배열 인자의 크기를 알려 줄 방법이 없음
conformant 배열 파라미터(크기를 앞에 선언)
14-04
non-blocking write가 EAGAIN일 때 대기 없이 재시도
재시도 사이에 시간을 주거나 select/poll로 대기
1. 에러 처리 — 13-01 ~ 13-06
errno는 믿을 수 없는 지점이 있다 (13-01)
요약
문제: printf 여러 번 뒤 errno != 0으로 출력 오류를 판단 (13-01)
원인: 성공한 호출도 errno를 바꾸고, 어느 호출의 값인지 알 수 없음
처방: 스트림 오류는 ferror(stdout), 쓰기 전 errno = 0 + 호출 직후에만 읽기. 다시 쓰려면 clearerr()
상세 보기
errno = 0;printf("This\n"); printf("is\n"); printf("a\n"); printf("test.\n");if (errno != 0) { ... } // 어느 호출이 실패했는지, 정말 printf 때문인지 알 수 없다
errno는 실패했을 때만 의미가 있고, 성공한 라이브러리 호출도 값을 바꿀 수 있다. 호출 사이에 끼면 신뢰할 수 없다.
표준 출력 스트림의 오류는 ferror(stdout)으로 한 번에 확인한다. 스트림의 에러 표시자는 한 번 설정되면 clearerr()를 부르기 전까지 남아 있어서, 중간 호출이 실패했어도 마지막에 한 번 검사하면 잡힌다.
(errno는 이후 호출이 덮어쓰지만 에러 표시자는 덮어써지지 않는다.) 같은 스트림을 다시 쓰려면 clearerr()로 초기화한다.
errno는 자동으로 0이 되지 않는다. 쓰기 전에 직접 errno = 0을 넣고, 호출 직후에만 읽는다.
in-band 에러 지시자를 피한다 (13-02)
요약
문제: count += sprintf(...)에서 오류 -1이 위치에 더해져 앞쪽 메모리를 덮음 (13-02)
문제: abort()로 종료하면 정리가 안 되고 버퍼가 써질지가 구현에 달림 (13-03)
원인: abort/_Exit는 atexit를 건너뜀. close(f)는 FILE *에 쓴 오류
처방: 종료는 return/exit, FILE *에는 fclose
상세 보기
방법
자원 정리
atexit
stdio 버퍼
return(main) / exit()
O
실행
비움
_Exit()
X (열린 파일은 OS가 닫음)
실행 안 함
구현 의존 (glibc 2.17에서 비우지 않음)
abort()
X (열린 파일은 OS가 닫음)
실행 안 함
구현 의존 (glibc 2.17에서 비움, 2.27 이후는 비우지 않는 것으로 알려짐)
13-03은 fprintf 뒤에 abort()로 끝내는 코드다. 버퍼가 파일에 쓰이는지는 구현에 달려 있어서(glibc 2.17 실측: abort() 13바이트, _Exit() 0바이트) 어느 쪽이든 결과에 기대면 안 된다. 측정 상세는 환경변수·시그널 실습의 atexit 항목.
abort()는 SIGABRT로 프로세스를 죽이므로 atexit 핸들러가 실행되지 않고 비정상 종료(코어 덤프)로 기록된다. 원인을 남기고 싶은 치명적 오류가 아니라면 exit(EXIT_FAILURE)로 끝낸다.
코드 뒤쪽의 close(f)는 FILE *에 close를 쓴 오류다(fclose여야 한다).
strtol/strtoul은 endptr(변환이 끝난 위치)와 errno(ERANGE)로 구분할 수 있다.
13-06의 strtoul(c_str, &endptr, 0)은 endptr == c_str(숫자 없음)과 ERANGE를 확인한다. 다만 호출 **전에 errno = 0**을 넣어야 이전 실패가 남아서 오판하지 않는다. 또 endptr이 문자열 끝이 아니면(12abc) 그것도 오류다.
2. 실습 1 — my_atoi (예외를 반환하는 atoi)
처음 코드의 문제
처음 코드의 문제와 고친 my_atoi
} ret_t // ① 세미콜론 없음 → 컴파일 에러int my_atoi(char *str, int *out){ char *end = NULL; errno = 0; *out = (int)strtol(str, end, 10); // ② &end 가 아니라 end (NULL 에 쓰려 함), *out 먼저 쓰고 if(str == NULL || out == NULL){...} // ③ NULL 검사가 사용 뒤에 있음 else if(errno = ERANGE && *out > LONG_MAX) // ④ = (대입), ⑤ int 를 LONG_MAX 와 비교 → 항상 거짓
처음 코드
문제
고친 코드
} ret_t
세미콜론 누락
} ret_t;
strtol(str, end, 10)
끝 위치를 받으려면 end의 주소
&end
NULL 검사가 *out = ... 뒤
NULL이면 검사 전에 크래시
맨 앞으로 이동
errno = ERANGE
대입이라 항상 참, errno 덮어씀
errno == ERANGE
*out > LONG_MAX
int를 long 한계와 비교 → 항상 거짓
long val로 받아 ERANGE와 INT_MAX/INT_MIN으로 판단
(int)strtol(...)
캐스팅이 값을 잘라 범위 초과를 놓침
검사 후 마지막에 캐스팅
end == str만 검사
12ab가 성공 처리됨
*end != '\0'이면 INVALID_CHAR
char *str
읽기만 하는 인자
const char *str
int my_atoi(const char *str, int *out){ char *end = NULL; long val = 0; if(str == NULL || out == NULL){ return INVALID_PARAM; } errno = 0; val = strtol(str, &end, 10); if(end == str || *end != '\0'){ return INVALID_CHAR; } if(errno == ERANGE){ return (val == LONG_MAX) ? OVERFLOW : UNDERFLOW; } if(val > INT_MAX){ return OVERFLOW; } if(val < INT_MIN){ return UNDERFLOW; } *out = (int)val; // 성공했을 때만 out 을 갱신 return OK;}
입력
반환
123, -45, 7, 0, 2147483647, -2147483648
OK
2147483648, 매우 큰 수
OVERFLOW
-2147483649, 매우 작은 수
UNDERFLOW
abc, 빈 문자열, 12ab, 12
INVALID_CHAR
str/out이 NULL
INVALID_PARAM
NOTE
enum의 typedef는 필수가 아니다. 상수만 필요하면 enum { OK = 1, ... };로 충분하다. 반환 타입으로 쓰려면 typedef enum {...} ret_t;와 ret_t my_atoi(...)로 쓴다.
C에서 enum은 정수 타입이라 int와 섞여 쓰이지만, 변수 타입이 enum일 때만 switch에서 상수 누락 경고(-Wswitch)를 받는다.
3. 실습 2 — snprintf_m (in-band 에러 제거)
요구사항과 처음 코드의 문제
요구사항: errno_t snprintf_m(char *buf, size_t buf_len, size_t *count, const char *fmt, ...). 반환값은 에러 값, 쓴 글자 수는 count로 전달한다.
처방: dstend = dst + dst_size - 1, 크기는 원소 개수로, 꽉 찬 입력 경계값으로 확인
상세 보기
int copy(char *dst, const char *src){ ... *dst = '\0'; } // int 함수인데 return 이 없다 (UB), dst 크기를 모른다
문자열은 포인터만 넘겨도 되지만, 받는 쪽 버퍼는 크기를 같이 받아야 오버플로를 검사할 수 있다.
크기를 받는 14-02-1도 1바이트 넘친다: dstend = dst + dst_size까지 복사한 뒤 *dst = '\0'을 쓰면 dst[dst_size]에 쓴다. 7칸 버퍼에 긴 문자열을 넣으면 dst[7]이 널이 되는 것을 확인했다.
dstend = dst + dst_size - 1이어야 한다.
교육자료(14-2)의 “수정안”도 같은 결함을 갖고 있다 (dstend = dst + dst_size). 16칸 배열을 'X'로 채우고 dst_size = 10으로 긴 문자열을 복사해 big[10]을 찍으니 0이었다.
즉 크기 10 밖에 널 문자를 썼다. 자료의 코드도 그대로 믿지 말고 경계값(꽉 찬 입력) 으로 직접 확인한다.
교육자료는 크기를 바이트가 아니라 원소 개수로 넘기라고 권한다 (char는 같지만 int 배열에서 차이가 난다).
strncpy/strncat의 n은 복사할 최대 길이이지 dest/src의 버퍼 크기가 아니므로, 크기를 따로 받는 improved_strncpy(dest, destcount, src, srccount, n) 같은 형태가 필요하다.
conformant 배열 파라미터 (14-03)
요약
문제: 배열 인자의 크기를 전달할 방법이 없음 (14-03)
원인: 배열 매개변수는 포인터라 sizeof(p)가 포인터 크기
처방: void f(size_t n, char p[n])처럼 크기를 앞에 선언 (뒤에 두면 GNU 확장). NULL·크기 검사는 별도로
상세 보기
void my_memset(size_t n, char p[n], char v); // 크기를 배열보다 먼저 선언
포인터로 넘기지만, 배열의 크기를 표현식으로 선언하는 C99 문법이다. 크기 매개변수가 배열 매개변수보다 앞에 있어야 한다.
뒤에 두려면 void f(size_t n; char p[n], size_t n)처럼 먼저 선언(forward declaration)해야 하는데, 이는 GNU 확장이다.
컴파일러가 실제 크기를 강제하지는 않는다. 매개변수의 배열은 포인터이므로 sizeof(p)는 배열 크기가 아니라 포인터 크기(8)다. 그래서 NULL 검사와 크기 비교는 여전히 필요하다.
non-blocking 재시도 (14-04)
요약
문제: EAGAIN에서 대기 없이 곧바로 재시도해 횟수만 소진 (14-04)
원인: 지금은 쓸 수 없다는 신호를 즉시 재시도로 처리
처방: 재시도 사이에 시간을 주거나 select/poll로 대기, write_len을 누적해 partial write 처리
상세 보기
rc = write(fd, pbuf + write_len, len - write_len);if (rc < 0) { if (errno == EWOULDBLOCK || errno == EAGAIN || errno == EINTR) { retry_cnt++; continue; } // 바로 다시 시도 ...}write_len += rc; // 일부만 써질 수 있다 (partial write)
non-blocking 소켓의 write는 보낼 수 없으면 EAGAIN/EWOULDBLOCK을 돌려준다. 대기 없이 곧바로 다시 시도하면 재시도 횟수만 소진되고 실패한다.
네트워크 지연 때문에 정상적으로 보내지는 중인데 못 받은 것처럼 보일 수 있으므로, 재시도 사이에 시간을 주거나(select/poll로 쓰기 가능을 기다림) 한다.
write는 일부만 쓸 수 있으므로 write_len을 누적해 len이 될 때까지 반복한다.
6. 실습 4 — conformant 배열로 만든 improved_strncpy/strncat
크기를 앞에 선언한 conformant 파라미터로 strncpy/strncat을 안전하게 만든 함수.